點擊進入React源碼調試倉庫。
上壹篇React狀態計算解密 之後,我們來分析壹下Diff的過程。
fiber上的updateQueue經過React的壹番計算之後,這個fiber已經有了新的狀態,也就是state,對於類組件來說,state是在render函數裏被使用的,既然已經得到了新的state,那麽當務之急是執行壹次render,得到持有新state的ReactElement。
假設render壹次之後得到了大量的ReactElement,而這些ReactElement之中若只有少量需要更新的節點,那麽顯然不能全部去更新它們,此時就需要有壹個diff過程來決定哪些節點是真正需要更新的。
我們以類組件為例,state的計算發生在類組件對應的fiber節點beginWork中的updateClassInstance
函數中,在狀態計算完畢之後,緊跟著就是去調finishClassComponent
執行diff、打上effectTag(即新版本的flag)。
打上effectTag可以標識這個fiber發生了怎樣的變化,例如:新增(Placement)、更新(Update)、刪除(Deletion),這些被打上flag的fiber會在complete階段被收集起來,形成壹個effectList鏈表,只包含這些需要操作的fiber,最後在commit階段被更新掉。
function updateClassComponent(
current: Fiber | null, workInProgress: Fiber, Component: any, nextProps: any, renderLanes: Lanes,) {
...
// 計算狀態
shouldUpdate = updateClassInstance(
current,
workInProgress,
Component,
nextProps,
renderLanes,
);
...
// 執行render,進入diff,為fiber打上effectTag
const nextUnitOfWork = finishClassComponent(
current,
workInProgress,
Component,
shouldUpdate,
hasContext,
renderLanes,
);
return nextUnitOfWork;
}
在finishClassComponent
函數中,調用reconcileChildFibers
去做diff,而reconcileChildFibers
實際上就是ChildReconciler
,這是diff的核心函數,該函數針對組件render生成的新節點的類型,調用不同的函數進行處理。
function ChildReconciler(shouldTrackSideEffects) {
...
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
// 單節點diff
}
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
// 多節點diff
}
...
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any, lanes: Lanes,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// 處理單節點
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_PORTAL_TYPE:
...
case REACT_LAZY_TYPE:
...
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 處理文本節點
}
if (isArray(newChild)) {
// 處理多節點
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
...
}
return reconcileChildFibers;
}
關於Diff的參與者,在reconcileChildren函數的入參中可以看出
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
比如現在有組件,它計算完新的狀態之後,要基於這兩個東西去做diff,分別是現有fiber樹中(current樹)對應fiber的所有子fiber節點和**的render函數的執行結果,即那些ReactElements**。
對應fiber的所有子fiber節點:oldFiber
current樹中
<Example/> fiber
|
|
A --sibling---> B --sibling---> C
的render函數的執行結果,newChildren
current fiber 對應的組件render的結果
[
{$$typeof: Symbol(react.element), type: "div", key: "A" },
{$$typeof: Symbol(react.element), type: "div", key: "B" },
{$$typeof: Symbol(react.element), type: "div", key: "C" },
]
對於新舊兩種結構來說,場景有節點自身更新、節點增刪、節點移動三種情況。面對復雜的情況,即使最前沿的算法,復雜度也極高。面對這種情況,React以如下策略應對:
舊
<div>
<span>a</span>
<span>b</span>
</div>
新
<p>
<span>a</span>
<span>b</span>
</p>
舊
<p key="a">aa</p>
<h1 key="b">bb</h1>
新
<h1 key="b">bb</h1>
<p key="a">aa</p>
因為tag 和 key的存在,所以React可以知道這兩個節點只是位置發生了變化。
上面說到diff算法應對三種場景:節點更新、節點增刪、節點移動
,但壹個fiber的子元素有可能是單節點,也有可能是多節點。所以依據這兩類節點可以再細分為:
什麽是節點的更新呢?對於DOM節點來說,在前後的節點類型(tag)和key都相同的情況下,節點的屬性發生了變化,是節點更新。若前後的節點tag或者key不相同,Diff算法會認為新節點和舊節點毫無關系。
以下例子中,key為b的新節點的className發生了變化,是節點更新。
舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>
新
<div className={'a'} key={'a'}>aa</div>
<div className={'bcd'} key={'b'}>bb</div>
以下例子中,新節點的className雖然有變化,但key也變化了,不屬於節點更新
舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>
新
<div className={'a'} key={'a'}>aa</div>
<div className={'bcd'} key={'bbb'}>bb</div>
以下例子中,新節點的className雖然有變化,但tag也變化了,不屬於節點更新
舊
<div className={'a'} key={'a'}>aa</div>
<div className={'b'} key={'b'}>bb</div>
新
<div className={'a'} key={'a'}>aa</div>
<p className={'bcd'} key={'b'}>bb</p>
下面來分開敘述壹下單節點和多節點它們各自的更新策略。
若組件產出的元素是如下的類型:
<div key="a">aa</div>
那麽它最終產出的ReactElement為下面這樣(省略了壹些與diff相關度不大的屬性)
{
$$typeof: Symbol(react.element), type: "div", key: "a"
...
}
單節點指newChildren為單壹節點,但是oldFiber的數量不壹定,所以實際有如下三種場景:
為了降低理解成本,我們用簡化的節點模型來說明問題,字母代表key。
舊: A
新: A
舊: A - B - C
新: B
舊: --
新: A
對於單節點的diff,其實就只有更新操作,不會涉及位移和位置的變化,單節點的更新會調用reconcileSingleElement
函數處理。該函數中對以上三種場景都做了覆蓋。但實際上面的情況對於React來說只是兩種,oldFiber鏈是否為空。因此,在實現上也只處理了這兩種情況。
遍歷它們,找到key相同的節點,然後刪除剩下的oldFiber節點,再用匹配的oldFiber,newChildren中新節點的props來生成新的fiber節點。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
switch (child.tag) {
case Fragment:
...
case Block:
...
default: {
if (child.elementType === element.type) {
// 先刪除剩下的oldFiber節點
deleteRemainingChildren(returnFiber, child.sibling);
// 基於oldFiber節點和新節點的props新建新的fiber節點
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber; return existing;
}
break;
}
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 沒匹配到說明新的fiber節點無法從oldFiber節點新建
// 刪除掉所有oldFiber節點
deleteChild(returnFiber, child);
}
child = child.sibling;
}
...
}
對於沒有oldFiber節點的情況,只能新建newFiber節點。邏輯不復雜。
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
// oldFiber鏈非空的處理
...
} if (element.type === REACT_FRAGMENT_TYPE) {
// 處理Fragment類型的節點
...
} else {
// 用產生的ReactElement新建壹個fiber節點
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
單節點的更新就是這樣的處理,真正比較復雜的情況是多節點的diff。因為它涉及到節點的增刪和位移。
若組件最終產出的DOM元素是如下這樣:
<div key="a">aa</div>
<div key="b">bb</div>
<div key="c">cc</div>
<div key="d">dd</div>
那麽最終的newChildren為下面這樣(省略了壹些與diff相關度不大的屬性)
[
{$$typeof: Symbol(react.element), type: "div", key: "a" },
{$$typeof: Symbol(react.element), type: "div", key: "b" },
{$$typeof: Symbol(react.element), type: "div", key: "c" },
{$$typeof: Symbol(react.element), type: "div", key: "d" }
]
多節點的變化有以下四種可能性。
舊: A - B - C
新: `A - B - C`
舊: A - B - C
新: A - B - C - `D - E`
舊: A - B - C - `D - E`
新: A - B - C
舊: A - B - C - D - E
新: A - B - `D - C - E`
多節點的情況壹定是屬於這四種情況的任意組合,這種情況會調用reconcileChildrenArray
進行diff。按照以上四種情況,它會以newChildren為主體進行最多三輪遍歷,但這三輪遍歷並不是相互獨立的,事實上只有第壹輪是從頭開始的,之後的每壹輪都是上輪結束的斷點繼續。實際上在平時的實踐中,節點自身的更新是最多的,所以Diff算法會優先處理更新的節點。因此四輪遍歷又可以按照場景分為兩部分:
第壹輪是針對節點自身屬性更新,剩下的兩輪依次處理節點的新增、移動,而重點又在移動節點的處理上,所以本文會著重講解節點更新和節點移動的處理,對刪除和新增簡單帶過。
第壹輪從頭開始遍歷newChildren,會逐個與oldFiber鏈中的節點進行比較,判斷節點的key或者tag是否有變化。
let newIdx = 0;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
...
// 更新節點,對於DOM節點來說,updateSlot內部會判斷
// key 和 tag。任意壹個不同,則返回null
const newFiber = updateSlot( returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber為null則說明當前的節點不是更新的場景,中止這壹輪循環
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
...
}
我們來看壹個例子,假設新舊的節點如下:
舊: A - B - C - D
- E
新: A - B - D - C
在本輪遍歷中,會遍歷A - B - D - C
。A和B都是key沒變的節點,可以直接復用,但當遍歷到D時,發現key變化了,跳出當前遍歷。例子中A 和 B是自身發生更新的節點,後面的D 和 C我們看到它的位置相對於oldFiber鏈發生了變化,會往下走到處理移動節點的循環中。
關於移動節點的參照物
為了方便說明,把保留在原位的節點稱為固定節點。經過這次循環的處理,可以看出固定節點是A 和 B。在newChildren中,最靠右的固定節點的位置至關重要,對於後續的移動節點的處理來說,它的意義是提供參考位置。所以,每當處理到最後壹個固定節點時,要記住此時它的位置,這個位置就是lastPlacedIndex
。關鍵代碼如下:
let newIdx = 0;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
...
// 跳出邏輯
...
// 如果不跳出,記錄最新的固定節點的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
...}
placeChild
方法實際上是移動節點的方法,但當節點無需移動的時候,會返回當前節點的位置,對於固定節點來說,因為無需移動,所以返回的就是固定節點的index。
我們沒有提到對刪除節點的處理,實際上刪除節點比較簡單。
舊: A - B - C - D - E
新: A - B - C
因為遍歷的是newChildren,當它遍歷結束,但oldFiber鏈還沒有遍歷完,那麽說明剩下的節點都要被刪除。直接在oldFiber節點上標記Deletion的effectTag來實現刪除。
if (newIdx === newChildren.length) {
// 新子節點遍歷完,說明剩下的oldFiber都是沒用的了,可以刪除
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
deleteRemainingChildren
調用了deleteChild
,值得註意的是,刪除不僅僅是標記了effectTag為Deletion,還會將這個被刪除的fiber節點添加到父級的effectList中。
function deleteChild(returnFiber: Fiber, childToDelete: Fiber): void {
...
const last = returnFiber.lastEffect;
// 將要刪除的child添加到父級fiber的effectList中,並添加上effectTag為刪除
if (last !== null) {
last.nextEffect = childToDelete;
returnFiber.lastEffect = childToDelete;
} else {
returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
}
childToDelete.nextEffect = null;
childToDelete.effectTag = Deletion;
}
新增節點的場景也很好理解,當oldFiber鏈遍歷完,但newChildren還沒遍歷完,那麽余下的節點都屬於新插入的節點,會新建fiber節點並以sibling為指針連成fiber鏈。
舊: A - B - C
新: A - B - C - D - E
插入的邏輯(省略了相關度不高的代碼)
if (oldFiber === null) {
// 舊的遍歷完了,意味著剩下的都是新增的了
for (; newIdx < newChildren.length; newIdx++) { // 首先創建newFiber
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
...
// 再將newFiber連接成以sibling為指針的單向鏈表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
節點的移動是如下場景:
舊 A - B - C - D - E - F
新 A - B - D - C - E
經過第壹輪遍歷的處理,固定節點為A B,最新的固定節點的位置(lastPlacedIndex)為1(B的位置)。此時oldFiber鏈中還剩C - D - E - F,newChildren中還剩D - C - E。
接下來的邏輯對於位置不壹樣的節點,它自己會先更新再移動。因為此時剩余的節點位置變了,更新又要復用oldFiber節點,所以為了在更新時方便查找,會將剩余的oldFiber節點放入壹個以key為鍵,值為oldFiber節點的map中。稱為existingChildren
。
由於newChildren 和 oldFiber節點都沒遍歷完,說明需要移動位置。此刻需要明確壹點,就是這些節點都在最新的固定節點的右邊。
移動的邏輯是:newChildren中剩余的節點,都是不確定要不要移動的,遍歷它們,每壹個都去看看這個節點在oldFiber鏈中的位置(舊位置),遍歷到的節點有它在newChildren中的位置(新位置):
如果舊位置在lastPlacedIndex的右邊,說明這個節點位置不變。
原因是舊位置在lastPlacedIndex的右邊,而新節點的位置也在它的右邊,所以它的位置沒變化。因為位置不變,所以它成了固定節點,把lastPlacedIndex更新成新位置。
如果舊位置在lastPlacedIndex的左邊,當前這個節點的位置要往右挪。
原因是舊位置在lastPlacedIndex的左邊,新位置卻在lastPlacedIndex的右邊,所以它要往右挪,但它不是固定節點。此時無需更新lastPlacedIndex。
我們來用上邊的例子過壹下這部分邏輯。
舊 A - B - C - D - E - F
新 A - B - D - C - E
位置固定部分 A - B,最右側的固定節點為B,lastPlacedIndex為1
。這時剩余oldFiber鏈為C - D - E - F,existingChildren為
{
C: '節點C',
D: '節點D',
E: '節點E',
F: '節點F'
}
newChildren的剩余部分D - C - E繼續遍歷。
首先遍歷到D,D在oldFiber鏈中(A - B - C - D - E)的位置為3
3 > 1
,oldFiber中D的位置在B的右邊,newChildren中也是如此,所以D的位置不動,此時最新的固定節點變成了D
,更新lastPlacedIndex為3
。並從existingChildren中刪除D,
{
C: '節點C',
E: '節點E',
F: '節點F'
}
再遍歷到C,C在oldFiber鏈中(A - B - C - D - E)的索引為2
2 < 3
,C原來在最新固定節點(D
)的左邊,newChildren中C在D
的右邊,所以要給它移動到右邊。並從existingChildren中刪除C。
{
E: '節點E',
F: '節點F'
}
再遍歷到E,E在oldFiber鏈中(A - B - C - D - E)的位置為4
4 > 3
,oldFiber鏈中E位置在D
的位置的右邊,新位置中也是如此,所以E的位置不動,此時最新的固定節點變成了E
,更新lastPlacedIndex為4
。並從existingChildren中刪除E,
{
F: '節點F'
}
這個時候newChildren都處理完了,針對移動節點的遍歷結束。此時還剩壹個F節點,是在oldFiber鏈中的,因為newChildren都處理完了,所以將它刪除即可。
existingChildren.forEach(child => deleteChild(returnFiber, child));
可以看到,節點的移動是以最右側的固定節點位置作為參照的。這些固定節點是指位置未發生變化的節點。每次對比節點是否需要移動之後,及時更新固定節點非常重要。
了解了上邊的多節點diff原理後,將上邊的關鍵點匹配到源碼上更方便能進壹步理解。下面放出帶有詳細註釋的源碼。
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
/* * returnFiber:currentFirstChild的父級fiber節點
* currentFirstChild:當前執行更新任務的WIP(fiber)節點
* newChildren:組件的render方法渲染出的新的ReactElement節點
* lanes:優先級相關
* */
// resultingFirstChild是diff之後的新fiber鏈表的第壹個fiber。
let resultingFirstChild: Fiber | null = null;
// resultingFirstChild是新鏈表的第壹個fiber。
// previousNewFiber用來將後續的新fiber接到第壹個fiber之後
let previousNewFiber: Fiber | null = null;
// oldFiber節點,新的child節點會和它進行比較
let oldFiber = currentFirstChild;
// 存儲固定節點的位置
let lastPlacedIndex = 0;
// 存儲遍歷到的新節點的索引
let newIdx = 0;
// 記錄目前遍歷到的oldFiber的下壹個節點
let nextOldFiber = null;
// 該輪遍歷來處理節點更新,依據節點是否可復用來決定是否中斷遍歷
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// newChildren遍歷完了,oldFiber鏈沒有遍歷完,此時需要中斷遍歷
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber; oldFiber = null;
} else {
// 用nextOldFiber存儲當前遍歷到的oldFiber的下壹個節點
nextOldFiber = oldFiber.sibling;
}
// 生成新的節點,判斷key與tag是否相同就在updateSlot中
// 對DOM類型的元素來說,key 和 tag都相同才會復用oldFiber
// 並返回出去,否則返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber為 null說明 key 或 tag 不同,節點不可復用,中斷遍歷
if (newFiber === null) {
if (oldFiber === null) {
// oldFiber 為null說明oldFiber此時也遍歷完了
// 是以下場景,D為新增節點
// 舊 A - B - C
// 新 A - B - C - D oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// shouldTrackSideEffects 為true表示是更新過程
if (oldFiber && newFiber.alternate === null) {
// newFiber.alternate 等同於 oldFiber.alternate
// oldFiber為WIP節點,它的alternate 就是 current節點
// oldFiber存在,並且經過更新後的新fiber節點它還沒有current節點,
// 說明更新後展現在屏幕上不會有current節點,而更新後WIP
// 節點會稱為current節點,所以需要刪除已有的WIP節點
deleteChild(returnFiber, oldFiber);
}
}
// 記錄固定節點的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 將新fiber連接成以sibling為指針的單向鏈表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
// 將oldFiber節點指向下壹個,與newChildren的遍歷同步移動
oldFiber = nextOldFiber;
}
// 處理節點刪除。新子節點遍歷完,說明剩下的oldFiber都是沒用的了,可以刪除.
if (newIdx === newChildren.length) {
// newChildren遍歷結束,刪除掉oldFiber鏈中的剩下的節點
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 處理新增節點。舊的遍歷完了,能復用的都復用了,所以意味著新的都是新插入的了
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
// 基於新生成的ReactElement創建新的Fiber節點
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
// 記錄固定節點的位置lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 將新生成的fiber節點連接成以sibling為指針的單向鏈表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 執行到這是都沒遍歷完的情況,把剩余的舊子節點放入壹個以key為鍵,值為oldFiber節點的map中
// 這樣在基於oldFiber節點新建新的fiber節點時,可以通過key快速地找出oldFiber
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 節點移動
for (; newIdx < newChildren.length; newIdx++) {
// 基於map中的oldFiber節點來創建新fiber
const newFiber = updateFromMap( existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes, );
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 因為newChildren中剩余的節點有可能和oldFiber節點壹樣,只是位置換了,
// 但也有可能是是新增的.
// 如果newFiber的alternate不為空,則說明newFiber不是新增的。
// 也就說明著它是基於map中的oldFiber節點新建的,意味著oldFiber已經被使用了,所以需
// 要從map中刪去oldFiber
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 移動節點,多節點diff的核心,這裏真正會實現節點的移動
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 將新fiber連接成以sibling為指針的單向鏈表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// 此時newChildren遍歷完了,該移動的都移動了,那麽刪除剩下的oldFiber
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
Diff算法通過key和tag來對節點進行取舍,可直接將復雜的比對攔截掉,然後降級成節點的移動和增刪這樣比較簡單的操作。對oldFiber和新的ReactElement節點的比對,將會生成新的fiber節點,同時標記上effectTag,這些fiber會被連到workInProgress樹中,作為新的WIP節點。樹的結構因此被壹點點地確定,而新的workInProgress節點也基本定型。這意味著,在diff過後,workInProgress節點的beginWork節點就完成了。接下來會進入completeWork階段。